本系列文章會在筆者的部落格繼續連載!Design System 101 感謝大家的閱讀!
在上一篇 FocusScope 中,我們介紹了 FocusScope 的概念以及架構,這篇將介紹如何實作 FocusScope。
本文同步上傳到筆者的個人部落格,裡面透過 Sandpack 直接編輯程式碼!
FocusScope 最重要的核心就是將其範圍內 (Scope) 找出所有 focusable 的元素,並且將其儲存起來。再來透過 focusManager 來控制 focusable 的元素,例如:focusManager.focusNext()、focusManager.focusPrevious()。
在這裡,範圍 (Scope) 指的是 FocusScope 組件中的 children。
<FocusScope>{children}</FocusScope>
首先,先建立 FocusScopeContext 將 focusManager 能夠傳遞給其子組件。而開發者可以在子組件透過 useFocusManager hook 取得 focusManager,進而根據不同的鍵盤事件控制 focus 的行為。
// focus-scope/context
export const FocusScopeContext = React.createContext(null);
export const useFocusManager = () => {
  const context = useContext(FocusScopeContext);
  if (!context) {
    throw new Error('useFocusManager hook must be used within a FocusManagerProvider');
  }
  return context.focusManager;
};
export const FocusScopeProvider = (props) => {
  return (
    <FocusScopeContext.Provider value={{ focusManager: props.focusManager }}>
      {props.children}
    </FocusScopeContext.Provider>
  );
};
Github - FocusScopeContext
focusable 元素接著,我們需要找出 Scope 裡所有 focusable 的元素,可以用 <span hidden ref={startRef} /> 與 <span hidden ref={endRef} /> 先將 Scope 的範圍包起來,再來遍歷 Scope 裡的所有元素,並且將其儲存起來。
export const FocusScope = ({ children, autoFocus = false, contain = false, restoreFocus = false }) => {
  const startRef = useRef(null);
  const endRef = useRef(null);
  const scopeRef = useRef([]);
  useEffect(() => {
    let node = startRef.current?.nextSibling;
    const nodes = [];
    while (node && node !== endRef.current) {
      nodes.push(node);
      node = node.nextSibling;
    }
    scopeRef.current = nodes;
  }, [children]);
  const focusManager = {}; // createFocusManager(scopeRef); Not yet implement
  return (
    <FocusScopeProvider focusManager={focusManager}>
      <span hidden ref={startRef} />
      {children}
      <span hidden ref={endRef} />
    </FocusScopeProvider>
  );
};
再來,建立一個 createFocusManager,它會回傳一個物件,其包含了四種方法:
focusNext: 將 focus 移至下一個 focusable 元素focusPrevious: 將 focus 移至上一個 focusable 元素focusFirst: 將 focus 移至第一個 focusable 元素focusLast: 將 focus 移至最後一個 focusable 元素這四種方法可以讓開發者根據不同的鍵盤事件來控制 focus 的行為。
在實作 createFocusManager 之前,我們先來介紹一下 TreeWalker
TreeWalker?TreeWalker 是一個 DOM 的物件,可以用來導航和遍歷 DOM 的結構,也就是可以使用它遍歷 DOM 元素,並可以根據特定的過濾條件查找節點 (node),這讓我們找 DOM 中某些特定的節點變得非常容易。
TreeWalker?假設在一個頁面中,找出 focusable 的元素,並且我們已經將這些元素加入 data-focusable 屬性,這時候我們就可以透過 TreeWalker 來找出這些元素。
可以看到下圖的 console, 我們只有印出有 data-focusable 屬性的元素
<body>
  <div id="root">
    <button data-focusable>I'm focusable No.1</button>
    <br />
    <button data-focusable>I'm focusable No.2</button>
    <br />
    <span>I'm not focusable</span>
    <br />
    <button data-focusable>I'm focusable No.3</button>
  </div>
</body>
<script>
  function focusableFilter(node) {
    return node.hasAttribute('data-focusable') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
  }
  const walker = document.createTreeWalker(
    document.querySelector('#root'),
    NodeFilter.SHOW_ELEMENT,
    { acceptNode: focusableFilter },
    false,
  );
  const focusableElements = [];
  while (walker.nextNode()) {
    focusableElements.push(walker.currentNode);
  }
  console.log(focusableElements); // 這將列印出所有 'data-focusable' 屬性的元素
</script>

介紹完 TreeWalker 之後,就可以來實作 createFocusManager 了!
這邊當 TreeWalker 在遍 node 是 focusable 以及該 node 是在 Scope 內,就會將其加入 focusableElements 陣列中。
// 確認元素是否在 Scope 中
export function isElementInScope(el, scope) {
  if (!scope || !el) {
    return false;
  }
  return scope.includes(el) || scope.some((node) => node.contains(el));
}
export function getFocusableTreeWalker(root, opts, scope) {
  // Source: https://github.com/JingHuangSu1996/tocino/blob/main/packages/components/focus-scope/src/utils/index.tsx#L19-L39
  const selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR;
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
    acceptNode: (node) => {
      if (opts.from?.contains(node)) {
        return NodeFilter.FILTER_REJECT;
      }
      if (node.matches(selector) && (!scope || isElementInScope(node, scope))) {
        return NodeFilter.FILTER_ACCEPT;
      }
      return NodeFilter.FILTER_SKIP;
    },
  });
  if (opts.from) {
    walker.currentNode = opts.from;
  }
  return walker;
}
接著,建立 FocusManager,這邊我們只實作 focusNext,其餘的 focusPrevious、focusFirst、focusLast 皆是類似的實作方式 (完整實作可以點這裡)!
還記得我們一開始在 Scope 外層包了兩個 <span hidden ref={startRef} /> 與 <span hidden ref={endRef} /> 嗎? 這時我們就可以透過這兩個元素來當作 sentinel,並且用 walker 去遍歷 Scope 中的所有元素。
// 建立 FocusManager
export const createFocusManager = (scopeRef) => {
  const getSentinelStart = (scope) => scope[0].previousElementSibling;
  const focusNode = (node) => {
    if (node) {
      focusElement(node);
    }
    return node;
  };
  return {
    focusNext: (opts = {}) => {
      const scope = scopeRef.current;
      const { from, tabbable } = opts;
      const node = from || document.activeElement;
      const sential = getSentinelStart(scope);
      const walker = getFocusableTreeWalker(getScopeRoot(scope), { tabbable }, scope);
      walker.currentNode = isElementInScope(node, scope) ? node : sential;
      let nextNode = walker.nextNode();
      return focusNode(nextNode);
    },
  };
};
在這裡可以透過下面的 CodeSandbox 來玩看看,當我們按下 -> 鍵時,focus 就會移至下一個 focusable 元素。

wrap 的情況可以看到上面的動畫,當 -> 按到最後一個元素時,focus 就不會再往下移動了,如果想要讓跳回第一個,我們就需要加入 wrap 的功能。
而 sentinel 在這裡就扮演重要的角色, 當 focus 移至 Scope 的最後一個元素時,就會移至 sentinel,這時候我們就可以將 walker.currentNode 設定為 sentinel,這樣就可以讓 walker 再次從 Scope 的第一個元素開始遍歷。
// 建立 FocusManager
export const createFocusManager = (scopeRef) => {
  const getSentinelStart = (scope) => scope[0].previousElementSibling;
  const focusNode = (node) => {
    if (node) {
      focusElement(node);
    }
    return node;
  };
  return {
    focusNext: (opts = {}) => {
      const scope = scopeRef.current;
      const { wrap, from, tabbable } = opts;
      const node = from || document.activeElement;
      const sential = getSentinelStart(scope);
      const walker = getFocusableTreeWalker(getScopeRoot(scope), { tabbable }, scope);
      walker.currentNode = isElementInScope(node, scope) ? node : sential;
      // ---- 新增 ----
      let nextNode = walker.nextNode();
      if (!nextNode && wrap) {
        walker.currentNode = sential;
        nextNode = walker.nextNode();
      }
      // -----------
      return focusNode(nextNode);
    },
  };
};

完成了 FocusScope 的基本核心之後,就可以實作一開始提到的 API 了!
useAutoFocus hook 會在 Scope 渲染時,將 focus 移至第一個 focusable 元素,並且透過 sharedState 來記錄當前的 Scope。
export const useAutoFocus = (scopeRef, autoFocus) => {
  useEffect(() => {
    if (!autoFocus) {
      return;
    }
    sharedState.activeScope = scopeRef.current;
    if (!isElementInScope(document.activeElement, sharedState.activeScope)) {
      focusFirstInScope(scopeRef.current);
    }
  }, [scopeRef, autoFocus]);
};
useRestoreFocus hook 會在 Scope 卸載時,將 focus 移至上一個 Scope 的 focusable 元素。
export const useRestoreFocus = (restoreFocus) => {
  useLayoutEffect(() => {
    const nodeToRestore = document.activeElement;
    return () => {
      if (restoreFocus && nodeToRestore) {
        requestAnimationFrame(() => {
          if (document.body.contains(nodeToRestore)) {
            focusElement(nodeToRestore);
          }
        });
      }
    };
  }, [restoreFocus]);
};
useFocusContainment 則是會監聽 keydown 事件,並且將 focus 維持在 Scope 中。
可以在 onKeyDown 的邏輯看見透過鍵盤的 Tab 事件,在 focus 移動時會持續判斷當前的 focus 是否在 Scope 中,如果不在就會將 focus 移至 Scope 中的第一個元素,反之當鍵盤事件是 Shift + Tab 時,就會將 focus 移至 Scope 中的最後一個元素。
export const useFocusContainment = (scopeRef, contain) => {
  const focusNode = useRef();
  useEffect(() => {
    if (!contain) {
      return;
    }
    const onKeyDown = (e) => {
      if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) {
        return;
      }
      const focusedElement = document.activeElement;
      const scope = scopeRef.current;
      if (!scope || !isElementInScope(focusedElement, scope)) {
        return;
      }
      const root = getScopeRoot(scope);
      const walker = getFocusableTreeWalker(root, { tabbable: true }, scope);
      walker.currentNode = focusedElement;
      const lastPosition = scope.length - 1;
      let nextElement = e.shiftKey ? walker.previousNode() : walker.nextNode();
      if (!nextElement) {
        walker.currentNode = e.shiftKey ? scope[lastPosition].nextElementSibling : scope[0].previousElementSibling;
        nextElement = e.shiftKey ? walker.previousNode() : walker.nextNode();
      }
      e.preventDefault();
      if (nextElement) {
        focusElement(nextElement);
      }
    };
    document.addEventListener('keydown', onKeyDown, false);
    return () => {
      document.removeEventListener('keydown', onKeyDown, false);
    };
  }, [scopeRef, contain]);
};
最後將這些 API 加入到 FocusScope 本身的邏輯中,就完成了 FocusScope 的實作!
export const FocusScope = ({ children, autoFocus = false, contain = false, restoreFocus = false }) => {
  const startRef = useRef(null);
  const endRef = useRef(null);
  const scopeRef = useRef([]);
  useEffect(() => {
    let node = startRef.current?.nextSibling;
    const nodes = [];
    while (node && node !== endRef.current) {
      nodes.push(node);
      node = node.nextSibling;
    }
    scopeRef.current = nodes;
  }, [children]);
  
  useAutoFocus(scopeRef, autoFocus);
  useFocusContainment(scopeRef, contain);
  useRestoreFocus(restoreFocus);
  const focusManager = createFocusManager(scopeRef);
  return (
    <FocusScopeProvider focusManager={focusManager}>
      <span hidden ref={startRef} />
      {children}
      <span hidden ref={endRef} />
    </FocusScopeProvider>
  );
};

(DEMO)
詳細的程式碼都可以點擊這個Github 連結來參考。
下一章將介紹 Slot 這個概念!See ya!